From d186a989b6808ff39fcd43a9f0b478aebc4aa346 Mon Sep 17 00:00:00 2001
From: zwlucas <lucas.fariamo08@gmail.com>
Date: Fri, 29 May 2026 17:21:48 -0300
Subject: feat: enhance maintenance management and heartbeat history features

- Updated Store interface to include methods for managing maintenances.
- Modified API functions to support pagination for heartbeat history and added CRUD operations for maintenances.
- Enhanced types to include paginated responses and initial log entries.
- Implemented maintenance management UI in the admin panel with create, edit, and delete functionalities.
- Updated service detail page to display paginated heartbeat history and improved chart rendering.
- Refactored status page to accommodate new data structures and ensure consistent data handling.

Signed-off-by: Lucas Faria Mendes <lucas.fariamo08@gmail.com>
---
 frontend/src/routes/services/[id]/+page.svelte | 287 +++++++++++++++++++------
 1 file changed, 225 insertions(+), 62 deletions(-)

(limited to 'frontend/src/routes/services/[id]/+page.svelte')

diff --git a/frontend/src/routes/services/[id]/+page.svelte b/frontend/src/routes/services/[id]/+page.svelte
index 36ea105..a7b8877 100644
--- a/frontend/src/routes/services/[id]/+page.svelte
+++ b/frontend/src/routes/services/[id]/+page.svelte
@@ -6,17 +6,41 @@
 	import type { Service, Heartbeat, ServiceStats } from '$lib/types';
 
 	let svc: Service | undefined = $state();
-	let history: Heartbeat[] = $state([]);
+	let chartHistory: Heartbeat[] = $state([]);
 	let stats: ServiceStats | null = $state(null);
 	let loading = $state(true);
+
+	let tableData: Heartbeat[] = $state([]);
+	let currentPage = $state(1);
+	let totalPages = $state(1);
+	let totalItems = $state(0);
+
 	let expandedError: number | null = $state(null);
 
+	const PER_PAGE = 50;
+
+	async function loadTablePage(pageNum: number) {
+		const res = await getHistory(Number($page.params.id), pageNum, PER_PAGE);
+		tableData = res.data;
+		currentPage = res.page;
+		totalPages = res.total_pages;
+		totalItems = res.total;
+	}
+
 	onMount(async () => {
 		try {
 			const all = await listServices();
 			svc = all.find((s) => s.id === Number($page.params.id));
-			history = await getHistory(Number($page.params.id));
+
+			const chartRes = await fetch(`/api/services/${$page.params.id}/history?page=1&per_page=50`);
+			if (chartRes.ok) {
+				const json = await chartRes.json();
+				chartHistory = json.data ?? json;
+			}
+
 			getServiceStats(Number($page.params.id)).then((s) => (stats = s)).catch(() => {});
+
+			await loadTablePage(1);
 		} catch {
 			// handle
 		} finally {
@@ -24,47 +48,117 @@
 		}
 	});
 
-	$effect(() => {
-		if (!svc && $page.params.id) {
-			getHistory(Number($page.params.id))
-				.then((h) => (history = h))
-				.catch(() => {});
-		}
-	});
-
 	onMount(() => {
 		const unsub = subscribeHeartbeats((hb) => {
 			if (hb.service_id !== Number($page.params.id)) return;
-			history = [...history, hb].slice(-50);
+			chartHistory = [...chartHistory, hb].slice(-50);
+			if (currentPage === 1) {
+				tableData = [hb, ...tableData].slice(0, PER_PAGE);
+				totalItems++;
+				totalPages = Math.max(1, Math.ceil(totalItems / PER_PAGE));
+			}
 		});
 		return unsub;
 	});
 
-	let chartPad = { t: 8, b: 20, l: 40, r: 12 };
-	let chartW = $derived(Math.max(history.length * 32, 300));
+	let containerWidth = $state(0);
+	let pad = { t: 8, b: 24, l: 44, r: 12 };
 	let chartH = 200;
-	let plotW = $derived(chartW - chartPad.l - chartPad.r);
-	let plotH = $derived(chartH - chartPad.t - chartPad.b);
-	let chartMax = $derived(Math.max(...history.map((h) => h.response_time_ms), 100));
-	let chartMin = $derived(0);
+	let plotW = $derived(Math.max(containerWidth - pad.l - pad.r, 0));
+	let plotH = $derived(chartH - pad.t - pad.b);
+
+	function niceNum(range: number, round: boolean): number {
+		const exp = Math.floor(Math.log10(range));
+		const frac = range / Math.pow(10, exp);
+		let nice: number;
+		if (round) {
+			if (frac <= 1.5) nice = 1;
+			else if (frac <= 3) nice = 2;
+			else if (frac <= 7) nice = 5;
+			else nice = 10;
+		} else {
+			if (frac <= 1) nice = 1;
+			else if (frac <= 2) nice = 2;
+			else if (frac <= 5) nice = 5;
+			else nice = 10;
+		}
+		return nice * Math.pow(10, exp);
+	}
+
+	let yAxis = $derived.by(() => {
+		const vals = chartHistory.map((h) => h.response_time_ms);
+		if (vals.length === 0) return { min: 0, max: 100, ticks: [{ val: 0, y: 0 }, { val: 100, y: 200 }] };
+		const rawMin = Math.min(...vals);
+		const rawMax = Math.max(...vals);
+		if (rawMax === rawMin) return { min: 0, max: Math.max(rawMax * 2, 100), ticks: [] };
+
+		const range = rawMax - rawMin;
+		const pad = Math.max(range * 0.15, 10);
+		let lo = Math.max(0, rawMin - pad);
+		let hi = rawMax + pad;
+
+		const tickStep = niceNum((hi - lo) / 4, true);
+		lo = Math.floor(lo / tickStep) * tickStep;
+		hi = Math.ceil(hi / tickStep) * tickStep;
+
+		const ticks: { val: number; y: number }[] = [];
+		for (let v = lo; v <= hi + tickStep * 0.001; v += tickStep) {
+			const y = pad.t + plotH - ((v - lo) / (hi - lo || 1)) * plotH;
+			ticks.push({ val: Math.round(v), y });
+		}
+		return { min: lo, max: hi, ticks };
+	});
+
+	let segments = $derived.by(() => {
+		const n = chartHistory.length;
+		if (n < 2) return [];
+		const { min, max } = yAxis;
+		return chartHistory.slice(0, -1).map((h, i) => {
+			const next = chartHistory[i + 1];
+			const x1 = pad.l + (i / (n - 1)) * plotW;
+			const x2 = pad.l + ((i + 1) / (n - 1)) * plotW;
+			const y1 = pad.t + plotH - ((h.response_time_ms - min) / (max - min || 1)) * plotH;
+			const y2 = pad.t + plotH - ((next.response_time_ms - min) / (max - min || 1)) * plotH;
+			return {
+				x1, y1, x2, y2,
+				color: h.is_up && next.is_up ? 'var(--green)' : 'var(--red)'
+			};
+		});
+	});
 
 	let dots = $derived(
-		history.map((h, i) => {
-			const x = history.length === 1
-				? chartPad.l + plotW / 2
-				: chartPad.l + (i / (history.length - 1)) * plotW;
-			const y = chartPad.t + plotH - ((h.response_time_ms - chartMin) / (chartMax - chartMin || 1)) * plotH;
+		chartHistory.map((h, i) => {
+			const { min, max } = yAxis;
+			const x = pad.l + (i / (Math.max(chartHistory.length - 1, 1))) * plotW;
+			const y = pad.t + plotH - ((h.response_time_ms - min) / (max - min || 1)) * plotH;
 			return { x, y, ...h };
 		})
 	);
 
-	let yTicks = $derived.by(() => {
-		const n = 4;
-		return Array.from({ length: n + 1 }, (_, i) => {
-			const val = chartMin + ((chartMax - chartMin) / n) * i;
-			const y = chartPad.t + plotH - ((val - chartMin) / (chartMax - chartMin || 1)) * plotH;
-			return { val: Math.round(val), y };
-		});
+	let areaPath = $derived.by(() => {
+		if (chartHistory.length < 2) return '';
+		const { min, max } = yAxis;
+		const baseline = pad.t + plotH;
+		const top = dots.map((d) => `${d.x},${d.y}`).join(' L ');
+		const bottom = dots.map((d) => d.x).reverse().map((x) => `${x},${baseline}`).join(' L ');
+		return `M ${top} L ${bottom} Z`;
+	});
+
+	let pages = $derived.by(() => {
+		const p: (number | string)[] = [];
+		const total = totalPages;
+		if (total <= 7) {
+			for (let i = 1; i <= total; i++) p.push(i);
+		} else {
+			p.push(1);
+			if (currentPage > 3) p.push('...');
+			const start = Math.max(2, currentPage - 1);
+			const end = Math.min(total - 1, currentPage + 1);
+			for (let i = start; i <= end; i++) p.push(i);
+			if (currentPage < total - 2) p.push('...');
+			p.push(total);
+		}
+		return p;
 	});
 </script>
 
@@ -143,39 +237,47 @@
 			<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
 				Tempo de Resposta (ms)
 			</h2>
-			<div class="relative h-48">
-				{#if history.length === 0}
+			<div bind:clientWidth={containerWidth} class="relative" style="height: 200px">
+				{#if chartHistory.length === 0}
 					<div class="flex h-full items-center justify-center text-xs text-[var(--text-muted)]">
 						Sem dados ainda
 					</div>
-				{:else if history.length === 1}
+				{:else if chartHistory.length === 1}
 					<div class="flex h-full flex-col items-center justify-center gap-1">
 						<span
 							class="text-3xl font-bold tabular-nums"
-							style="color: {history[0].is_up ? 'var(--green)' : 'var(--red)'}"
+							style="color: {chartHistory[0].is_up ? 'var(--green)' : 'var(--red)'}"
 						>
-							{history[0].response_time_ms}
+							{chartHistory[0].response_time_ms}
 							<span class="text-base font-normal text-[var(--text-secondary)]">ms</span>
 						</span>
 						<span class="text-xs text-[var(--text-muted)]">
-							{history[0].is_up ? 'Online' : 'Offline'} —
-							{history[0].status_code || 'timeout'}
+							{chartHistory[0].is_up ? 'Online' : 'Offline'} —
+							{chartHistory[0].status_code || 'timeout'}
 						</span>
 					</div>
 				{:else}
-					<svg class="h-full w-full" viewBox="0 0 {chartW} {chartH}" preserveAspectRatio="none">
-						{#each yTicks as tick}
+					<svg width={containerWidth} height={chartH}>
+						<defs>
+							<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
+								<stop offset="0%" stop-color="var(--green)" stop-opacity="0.15" />
+								<stop offset="100%" stop-color="var(--green)" stop-opacity="0.01" />
+							</linearGradient>
+						</defs>
+
+						<!-- grid horizontal -->
+						{#each yAxis.ticks as tick}
 							<line
-								x1={chartPad.l}
+								x1={pad.l}
 								y1={tick.y}
-								x2={chartW - chartPad.r}
+								x2={containerWidth - pad.r}
 								y2={tick.y}
 								stroke="var(--border-color)"
 								stroke-width="1"
 								stroke-dasharray="3,3"
 							/>
 							<text
-								x={chartPad.l - 6}
+								x={pad.l - 6}
 								y={tick.y + 3}
 								text-anchor="end"
 								fill="var(--text-muted)"
@@ -184,38 +286,56 @@
 							>
 						{/each}
 
-						<polyline
-							points={dots.map((d) => `${d.x},${d.y}`).join(' ')}
-							fill="none"
-							stroke="var(--green)"
-							stroke-width="2"
-							stroke-linecap="round"
-							stroke-linejoin="round"
-						/>
+						<!-- área preenchida -->
+						<path d={areaPath} fill="url(#areaGrad)" />
 
+						<!-- segmentos da linha -->
+						{#each segments as seg}
+							<line
+								x1={seg.x1}
+								y1={seg.y1}
+								x2={seg.x2}
+								y2={seg.y2}
+								stroke={seg.color}
+								stroke-width="2"
+								stroke-linecap="round"
+							/>
+						{/each}
+
+						<!-- pontos -->
 						{#each dots as d}
-							<circle cx={d.x} cy={d.y} r="3.5" fill={d.is_up ? 'var(--green)' : 'var(--red)'} />
+							<circle
+								cx={d.x}
+								cy={d.y}
+								r="3"
+								fill={d.is_up ? 'var(--green)' : 'var(--red)'}
+								stroke="var(--bg-card)"
+								stroke-width="1.5"
+							/>
 						{/each}
 					</svg>
+
+					<!-- eixo X: tempo -->
+					<div class="flex justify-between" style="padding-left: {pad.l}px; padding-right: {pad.r}px;">
+						<span class="text-[10px] text-[var(--text-muted)]">
+							{new Date(chartHistory[0].tested_at).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
+						</span>
+						<span class="text-[10px] text-[var(--text-muted)]">
+							{new Date(chartHistory[chartHistory.length - 1].tested_at).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
+						</span>
+					</div>
 				{/if}
 			</div>
-			<div class="mt-2 flex justify-between text-[10px] text-[var(--text-muted)]">
-				<span>{history[0]?.tested_at ? new Date(history[0].tested_at).toLocaleTimeString() : ''}</span>
-				<span
-				>{history[history.length - 1]?.tested_at
-					? new Date(history[history.length - 1].tested_at).toLocaleTimeString()
-					: ''}</span
-				>
-			</div>
 		</div>
 
-		<!-- Timeline compacta -->
-		<div class="rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] p-5">
+		<!-- Histórico paginado -->
+		<div class="rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] p-5 lg:col-span-3">
 			<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
-				Últimas Verificações
+				Histórico de Verificações
 			</h2>
+
 			<div class="flex flex-col gap-1.5">
-				{#each history.slice().reverse() as h}
+				{#each tableData as h}
 					<div>
 						<div class="flex items-center justify-between gap-2">
 							<div class="flex items-center gap-2">
@@ -256,6 +376,49 @@
 					</div>
 				{/each}
 			</div>
+
+			<!-- Paginação -->
+			{#if totalPages > 1}
+				<div class="mt-5 flex items-center justify-center gap-1.5">
+					<button
+						onclick={() => loadTablePage(currentPage - 1)}
+						disabled={currentPage <= 1}
+						class="rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-30"
+						style="color: var(--text-muted); {currentPage <= 1 ? '' : 'hover:bg-[var(--border-color)] hover:text-white'}"
+					>
+						Anterior
+					</button>
+
+					{#each pages as p}
+						{#if p === '...'}
+							<span class="px-1 text-xs text-[var(--text-muted)]">…</span>
+						{:else}
+							<button
+								onclick={() => loadTablePage(p as number)}
+								class="min-w-[28px] rounded-lg px-2 py-1.5 text-xs font-medium transition-all"
+								style={p === currentPage
+									? 'background-color: var(--green); color: #000; font-weight: 700;'
+									: 'color: var(--text-muted); hover:background-color: var(--border-color); hover:color: white;'}
+							>
+								{p}
+							</button>
+						{/if}
+					{/each}
+
+					<button
+						onclick={() => loadTablePage(currentPage + 1)}
+						disabled={currentPage >= totalPages}
+						class="rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-30"
+						style="color: var(--text-muted); {currentPage >= totalPages ? '' : 'hover:bg-[var(--border-color)] hover:text-white'}"
+					>
+						Próximo
+					</button>
+				</div>
+
+				<p class="mt-2 text-center text-[10px] text-[var(--text-muted)]">
+					Página {currentPage} de {totalPages} — {totalItems} verificações no total
+				</p>
+			{/if}
 		</div>
 	</div>
 {/if}
-- 
cgit v1.2.3

